-
Notifications
You must be signed in to change notification settings - Fork 31
Feat/image-upload-component #3654
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
📝 WalkthroughWalkthroughAdds a new ImageUpload feature (components, hooks, utils, styles, tests), updates Dropzone API and FileUpload integration to accept file-type mappings, extends Card API for styling refs, adjusts card/media CSS, and adds localization keys and minor summary/option prop changes. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Suggested reviewers
Pre-merge checks and finishing touches❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…tsimplify mouse dragging and support touch dragging (#3656)
* reuse dropzonecomponent and display only when imgsrc is not defined * bring back the reset button * move controller section to its own file
* move image cropper into card wip * working cropping component in card * conforrm to styling naming in repo * canvas height adjustment --------- Co-authored-by: lassopicasso <[email protected]>
* clean up css and style closer to figma design
…fn) (#3668) * added save and cancel buttons with functionality (some left for save fn) * Adding AttachmentsPlugin to ImageUpload component (it needs to be defined as a form component for this to work, which is more fitting) * save cropped image to backend * feedback * small fix --------- Co-authored-by: Ole Martin Handeland <[email protected]>
* added save and cancel buttons with functionality (some left for save fn) * Adding AttachmentsPlugin to ImageUpload component (it needs to be defined as a form component for this to work, which is more fitting) * save cropped image to backend * redesign slider + buttons --------- Co-authored-by: Ole Martin Handeland <[email protected]>
* wip * revert unneccesary change * clean up
* add hook that handles the saved image and controllers buttons when saved
* support custom config * support mismatch of height and width if type is circle
* remove dropzone icon and refactor css * replace look of placeholder * remove unused css * remove focus for now
* cursor change to grabbing when active in canvas * make change image button support keyboard
* add validationmessages for imageupload size and types * clear validationmessages on cancel
* add language texts and use them throughout the component * change wording slightly and remove unused text * make file-endings the same
/publish |
PR release:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
♻️ Duplicate comments (4)
src/layout/ImageUpload/imageUploadUtils.ts (4)
34-45
: Use natural image dimensions; handle zero to avoid Infinity/NaN.This issue was flagged in a previous review:
HTMLImageElement.width/height
returns CSS pixels, but pixel-accurate canvas calculations requirenaturalWidth/naturalHeight
. Additionally, guard against zero dimensions to preventInfinity/NaN
in clamp calculations.Refer to the previous review comment for the complete fix.
54-61
: Use natural image dimensions; handle zero to avoid Infinity/NaN.This function has the same issue flagged in the previous review: use
naturalWidth/naturalHeight
instead ofwidth/height
for accurate canvas calculations, and guard against zero dimensions.Refer to the previous review comment for the complete fix.
98-115
: Guard zoom math against invalid ranges; clamp and avoid NaN/∞.This issue was flagged in a previous review:
Math.log
with non-positiveminZoom/maxZoom/value
yields-Infinity/NaN
. The code needs input validation and output clamping to keep the slider domain stable.Refer to the previous review comment for the complete fix.
118-119
: Use natural image dimensions; handle zero to avoid Infinity/NaN.This function has the same issue flagged in the previous review: use
naturalWidth/naturalHeight
instead ofwidth/height
, and guard against zero dimensions to prevent returningInfinity
.Refer to the previous review comment for the complete fix.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (9)
src/language/texts/en.ts
(1 hunks)src/language/texts/nb.ts
(2 hunks)src/language/texts/nn.ts
(2 hunks)src/layout/ImageUpload/ImageControllers.tsx
(1 hunks)src/layout/ImageUpload/ImageCropper.tsx
(1 hunks)src/layout/ImageUpload/ImageDropzone.tsx
(1 hunks)src/layout/ImageUpload/ImageUploadSummary2.test.tsx
(1 hunks)src/layout/ImageUpload/imageUploadUtils.test.tsx
(1 hunks)src/layout/ImageUpload/imageUploadUtils.ts
(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
- src/language/texts/nn.ts
- src/layout/ImageUpload/ImageUploadSummary2.test.tsx
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
**/*.{ts,tsx}
: Avoid usingany
and unnecessary type casts (as Type
) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options viaqueryOptions
Files:
src/layout/ImageUpload/ImageCropper.tsx
src/layout/ImageUpload/ImageControllers.tsx
src/layout/ImageUpload/ImageDropzone.tsx
src/language/texts/nb.ts
src/layout/ImageUpload/imageUploadUtils.ts
src/layout/ImageUpload/imageUploadUtils.test.tsx
src/language/texts/en.ts
**/*.test.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
In tests, use
renderWithProviders
fromsrc/test/renderWithProviders.tsx
to supply required form layout context
Files:
src/layout/ImageUpload/imageUploadUtils.test.tsx
🧬 Code graph analysis (4)
src/layout/ImageUpload/ImageCropper.tsx (7)
src/layout/ImageUpload/imageUploadUtils.ts (8)
CropArea
(8-8)Position
(1-1)calculateMinZoom
(118-119)constrainToArea
(34-45)imagePlacement
(54-61)validateFile
(121-144)cropAreaPlacement
(66-70)drawCropArea
(79-87)src/layout/ImageUpload/useImageFile.tsx (1)
useImageFile
(17-52)src/layout/ImageUpload/ImageDropzone.tsx (1)
ImageDropzone
(18-58)src/app-components/Card/Card.tsx (1)
AppCard
(20-54)src/layout/ImageUpload/ImageCanvas.tsx (1)
ImageCanvas
(25-161)src/layout/ImageUpload/ImageControllers.tsx (1)
ImageControllers
(25-155)src/features/language/Lang.tsx (1)
Lang
(15-23)
src/layout/ImageUpload/ImageControllers.tsx (4)
src/features/attachments/index.ts (1)
UploadedAttachment
(20-20)src/features/language/useLanguage.ts (1)
useLanguage
(90-93)src/layout/ImageUpload/imageUploadUtils.ts (3)
normalToLogZoom
(104-107)isAnimationFile
(146-149)logToNormalZoom
(109-115)src/features/language/Lang.tsx (1)
Lang
(15-23)
src/layout/ImageUpload/ImageDropzone.tsx (4)
src/app-components/Dropzone/Dropzone.tsx (2)
IDropzoneProps
(15-27)Dropzone
(31-99)src/hooks/useDeviceWidths.ts (1)
useIsMobileOrTablet
(33-35)src/components/label/Label.tsx (1)
getDescriptionId
(135-141)src/features/language/Lang.tsx (1)
Lang
(15-23)
src/layout/ImageUpload/imageUploadUtils.test.tsx (1)
src/layout/ImageUpload/imageUploadUtils.ts (10)
getCropArea
(10-25)constrainToArea
(34-45)imagePlacement
(54-61)cropAreaPlacement
(66-70)drawCropArea
(79-87)normalToLogZoom
(104-107)logToNormalZoom
(109-115)calculateMinZoom
(118-119)validateFile
(121-144)isAnimationFile
(146-149)
🔇 Additional comments (6)
src/layout/ImageUpload/imageUploadUtils.ts (6)
1-25
: LGTM! Clean type definitions and getCropArea logic.The type definitions are precise, and the
getCropArea
function correctly enforces the circle constraint (width = height) by using the minimum dimension when the type isCircle
.
66-70
: LGTM! Correct centering calculation.The function correctly centers the crop area within the canvas.
79-87
: LGTM! Correct canvas drawing for both crop shapes.The function properly handles both circle (using
arc
) and square (usingrect
) crop areas.
121-144
: LGTM! File validation logic is correct.The validation correctly checks:
- File presence
- Size limit (10 MB)
- Image type verification
The constants
MAX_FILE_SIZE_MB
andMAX_FILE_SIZE_BYTES
are currently scoped inside the function. If these need to be referenced externally (as suggested by the AI summary), consider exporting them at the module level.
146-149
: LGTM! Animation detection logic is correct.The function properly detects animation mime types with case-insensitive matching.
1-1
: Note: Past review comment aboutgetAcceptedFiles
doesn't apply to this file.One of the past review comments mentions a
getAcceptedFiles
function with accept string validation issues. This function is not present inimageUploadUtils.ts
. The comment may refer to a different file or the function may have been moved elsewhere.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
♻️ Duplicate comments (4)
src/layout/ImageUpload/ImageCropper.tsx (2)
99-114
: Clean up pending Image/FileReader to avoid leaks and stale callbacks
When replacing files or unmounting mid‑load, the previous Image can continue loading. Track and cancel previous loaders; guardonload
against stale updates.+ const loadingImageRef = useRef<HTMLImageElement | null>(null); + const fileReaderRef = useRef<FileReader | null>(null); const handleFileUpload = (file: File) => { const validationErrors = validateFile(file); setValidationErrors(validationErrors); if (validationErrors.length > 0) { return; } - - const reader = new FileReader(); + // Cleanup any in-flight loaders + if (loadingImageRef.current) { + loadingImageRef.current.onload = null; + loadingImageRef.current.src = ''; + loadingImageRef.current = null; + } + if (fileReaderRef.current && 'readyState' in fileReaderRef.current) { + try { fileReaderRef.current.abort?.(); } catch {} + } + const reader = new FileReader(); + fileReaderRef.current = reader; reader.onload = (event) => { const result = event.target?.result; if (typeof result === 'string') { const img = new Image(); + loadingImageRef.current = img; img.id = file.name; imageTypeRef.current = file.type; img.onload = () => { - updateImageState({ minZoom: calculateMinZoom({ img, cropArea }), img }); + if (loadingImageRef.current === img) { + updateImageState({ minZoom: calculateMinZoom({ img, cropArea }), img }); + loadingImageRef.current = null; + } }; img.src = result; } }; reader.readAsDataURL(file); }; + React.useEffect(() => () => { + if (loadingImageRef.current) { + loadingImageRef.current.onload = null; + loadingImageRef.current.src = ''; + } + try { fileReaderRef.current?.abort?.(); } catch {} + }, []);
136-144
: Handle canvas.toBlob failure and report to userIf
toBlob
returns null, provide feedback; also wrap File creation to catch exceptions.- cropCanvas.toBlob((blob) => { - if (!blob) { - return; - } - const fileName = img.id; - const imageFile = new File([blob], fileName, { type: 'image/png' }); - saveImage(imageFile); - setValidationErrors(null); - }, 'image/png'); + cropCanvas.toBlob((blob) => { + if (!blob) { + console.error('Failed to create image blob'); + setValidationErrors(['image_upload_component.error_generate_image_failed']); + return; + } + try { + const fileName = img.id; + const imageFile = new File([blob], fileName, { type: 'image/png' }); + saveImage(imageFile); + setValidationErrors(null); + } catch (e) { + console.error('Failed to save image', e); + setValidationErrors(['image_upload_component.error_generate_image_failed']); + } + }, 'image/png');src/layout/ImageUpload/ImageControllers.tsx (2)
45-53
: Fix zoom slider normalization (broken zoom and thumb position)Utils expect 0–1, slider is 0–100. Divide input by 100; multiply value by 100.
const handleSliderZoom = (e: React.ChangeEvent<HTMLInputElement>) => { const logarithmicZoomValue = normalToLogZoom({ - value: Number.parseFloat(e.target.value), + value: Number.parseFloat(e.target.value) / 100, minZoom, maxZoom, }); @@ <input id={zoomId} type='range' min='0' max='100' step='0.5' - value={logToNormalZoom({ value: zoom, minZoom, maxZoom })} + value={logToNormalZoom({ value: zoom, minZoom, maxZoom }) * 100} onChange={handleSliderZoom} className={classes.zoomSlider} />Also applies to: 89-96
99-109
: Respect readOnly and add accessible name for Reset; simplify “Change” to a real button
- Disable Save/Reset/Change/Cancel when readOnly.
- Provide aria-label for the icon-only Reset button.
- Avoid
asChild
with a Label; use a real Button that clicks the input ref so disabled works.
[accessibility]- <Button - onClick={onReset} - variant='tertiary' - icon={true} - > - <ArrowUndoIcon - title={langAsString('image_upload_component.reset')} - className={classes.resetButton} - /> - </Button> + <Button + onClick={onReset} + variant='tertiary' + icon + aria-label={langAsString('image_upload_component.button_reset')} + disabled={readOnly} + > + <ArrowUndoIcon className={classes.resetButton} /> + </Button> @@ <Button onClick={onSave} data-size='sm' data-color='accent' + disabled={readOnly} > <Lang id='image_upload_component.button_save' /> </Button> @@ - <Button - asChild - data-size='sm' - variant='secondary' - data-color='accent' - tabIndex={0} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - fileInputRef?.current?.click(); - } - }} - > - <Label htmlFor={inputId}> - <UploadIcon /> - <Lang id='image_upload_component.button_change' /> - </Label> - </Button> + <Button + data-size='sm' + variant='secondary' + data-color='accent' + onClick={() => fileInputRef.current?.click()} + disabled={readOnly} + > + <UploadIcon /> + <Lang id='image_upload_component.button_change' /> + </Button> <Button data-size='sm' variant='tertiary' onClick={onCancel} data-color='accent' + disabled={readOnly} > <Lang id='general.cancel' /> </Button>Also applies to: 112-118, 128-145, 146-153
🧹 Nitpick comments (3)
src/layout/ImageUpload/ImageUploadComponent.tsx (2)
33-36
: Remove unnecessary type cast on getCropArea inputThe object passed to getCropArea matches its expected params; the
as CropArea
cast is unnecessary and misleads types. Drop the cast.
[typescript] [As per coding guidelines]- cropArea={getCropArea({ width: cropWidth, height: cropHeight, type: cropShape } as CropArea)} + cropArea={getCropArea({ width: cropWidth, height: cropHeight, type: cropShape })}
21-36
: Verify that Label htmlFor matches the interactive control idLabel uses
htmlFor={id}
, while ImageDropzone setsid={baseComponentId}
. In repeating groups, these can differ (indexed id). Ensure the same id is used for both to keep label association and a11y intact (pass the actual control id down to ImageCropper/ImageDropzone if needed).If misaligned, prefer passing the indexed id from useLabel/useIndexedId to Dropzone.
src/layout/ImageUpload/ImageControllers.tsx (1)
123-125
: Align accepted file types with policy (extensions/types)Restrict accept to the allowed types from the PR: png, jpg/jpeg, heic, webp.
- accept='image/*' + accept='.png,.jpg,.jpeg,.heic,.webp,image/png,image/jpeg,image/heic,image/webp'Please confirm HEIC support in your target browsers/environments.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
src/layout/ImageUpload/ImageControllers.tsx
(1 hunks)src/layout/ImageUpload/ImageCropper.tsx
(1 hunks)src/layout/ImageUpload/ImageUploadComponent.test.tsx
(1 hunks)src/layout/ImageUpload/ImageUploadComponent.tsx
(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- src/layout/ImageUpload/ImageUploadComponent.test.tsx
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
**/*.{ts,tsx}
: Avoid usingany
and unnecessary type casts (as Type
) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options viaqueryOptions
Files:
src/layout/ImageUpload/ImageCropper.tsx
src/layout/ImageUpload/ImageControllers.tsx
src/layout/ImageUpload/ImageUploadComponent.tsx
🧬 Code graph analysis (3)
src/layout/ImageUpload/ImageCropper.tsx (7)
src/layout/ImageUpload/imageUploadUtils.ts (8)
CropArea
(8-8)Position
(1-1)calculateMinZoom
(118-119)constrainToArea
(34-45)imagePlacement
(54-61)validateFile
(121-144)cropAreaPlacement
(66-70)drawCropArea
(79-87)src/layout/ImageUpload/useImageFile.tsx (1)
useImageFile
(17-52)src/layout/ImageUpload/ImageDropzone.tsx (1)
ImageDropzone
(18-58)src/app-components/Card/Card.tsx (1)
AppCard
(20-54)src/layout/ImageUpload/ImageCanvas.tsx (1)
ImageCanvas
(25-161)src/layout/ImageUpload/ImageControllers.tsx (1)
ImageControllers
(26-157)src/features/language/Lang.tsx (1)
Lang
(15-23)
src/layout/ImageUpload/ImageControllers.tsx (4)
src/features/attachments/index.ts (1)
UploadedAttachment
(20-20)src/features/language/useLanguage.ts (1)
useLanguage
(90-93)src/layout/ImageUpload/imageUploadUtils.ts (3)
normalToLogZoom
(104-107)isAnimationFile
(146-149)logToNormalZoom
(109-115)src/features/language/Lang.tsx (1)
Lang
(15-23)
src/layout/ImageUpload/ImageUploadComponent.tsx (6)
src/layout/index.ts (1)
PropsFromGenericComponent
(29-33)src/utils/layout/useNodeItem.ts (1)
useItemWhenType
(15-33)src/utils/layout/useLabel.tsx (1)
useLabel
(13-72)src/layout/ComponentStructureWrapper.tsx (1)
ComponentStructureWrapper
(20-48)src/layout/ImageUpload/ImageCropper.tsx (1)
ImageCropper
(29-213)src/layout/ImageUpload/imageUploadUtils.ts (2)
getCropArea
(10-25)CropArea
(8-8)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
- GitHub Check: Type-checks, eslint, unit tests and SonarCloud
- GitHub Check: Install
- GitHub Check: Analyze (javascript)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
🧹 Nitpick comments (1)
src/layout/ImageUpload/ImageUploadComponent.tsx (1)
35-35
: Consider simplifying boolean coercion.The
!!readOnly
double negation is defensive but unnecessary ifreadOnly
is already typed asboolean | undefined
andImageCropper
acceptsreadOnly?: boolean
. Simply passingreadOnly
would be cleaner.Apply this diff if the types align:
- readOnly={!!readOnly} + readOnly={readOnly}
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
src/layout/FileUpload/FileUploadComponent.test.tsx
(1 hunks)src/layout/ImageUpload/ImageUploadComponent.tsx
(1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
**/*.{ts,tsx}
: Avoid usingany
and unnecessary type casts (as Type
) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options viaqueryOptions
Files:
src/layout/FileUpload/FileUploadComponent.test.tsx
src/layout/ImageUpload/ImageUploadComponent.tsx
**/*.test.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
In tests, use
renderWithProviders
fromsrc/test/renderWithProviders.tsx
to supply required form layout context
Files:
src/layout/FileUpload/FileUploadComponent.test.tsx
🧬 Code graph analysis (1)
src/layout/ImageUpload/ImageUploadComponent.tsx (6)
src/layout/index.ts (1)
PropsFromGenericComponent
(29-33)src/utils/layout/useNodeItem.ts (1)
useItemWhenType
(15-33)src/utils/layout/useLabel.tsx (1)
useLabel
(13-72)src/layout/ComponentStructureWrapper.tsx (1)
ComponentStructureWrapper
(20-48)src/layout/ImageUpload/ImageCropper.tsx (1)
ImageCropper
(29-213)src/layout/ImageUpload/imageUploadUtils.ts (1)
getCropArea
(10-25)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: Install
- GitHub Check: Type-checks, eslint, unit tests and SonarCloud
🔇 Additional comments (2)
src/layout/FileUpload/FileUploadComponent.test.tsx (1)
476-476
: LGTM! Translation refinement.The test expectation has been correctly updated to reflect the refined Norwegian text from "let etter fil" to "finn fil". This aligns with the updated UI text in the Dropzone component.
src/layout/ImageUpload/ImageUploadComponent.tsx (1)
12-16
: LGTM! readOnly now propagated correctly.The component now correctly destructures
readOnly
fromuseItemWhenType
and passes it through toImageCropper
(line 35), addressing the previous review concern.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fantastic stuff!! 🚀 This is a solid delivery. I have some comments, but nothing major.
src/layout/ImageUpload/config.ts
Outdated
.addProperty( | ||
new CG.prop( | ||
'cropShape', | ||
new CG.enum('square', 'circle') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The 'square' setting can also be a rectangle, so that might be a better naming. It's only a square if you set cropWidth
and cropHeight
to the same values, but if you specify different values you'll make a rectangle, not a square.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree, will change it.
.addProperty( | ||
new CG.prop( | ||
'cropWidth', | ||
new CG.num().optional({ default: 250 }).setTitle('Width').setDescription('Optional width of the cropping area'), | ||
), | ||
) | ||
.addProperty( | ||
new CG.prop( | ||
'cropHeight', | ||
new CG.num().optional({ default: 250 }).setTitle('Height').setDescription('Optional height of the cropping area'), | ||
), | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You might want to add the detail that for 'circle' only the smallest of these two actually have an effect. Thus, maybe 'circle' is not as great as a default value - since these two options have weird effects? You could potentially also add a union type that removes the gotchas in this config, such as only allowing objects:
"crop": {
"shape": "circle",
"diameter": 250
}
"crop": {
"shape": "rectangle",
"width": 250,
"height": 250
}
Example of how to do this in config.ts
:
Config.addProperty(
new CG.prop(
'crop',
new CG.union(
new CG.obj(
new CG.prop('shape', new CG.const('circle').setTitle('Shape').setDescription('Circular cropping area')),
new CG.prop(
'diameter',
new CG.num().optional({ default: 250 }).setTitle('Diameter').setDescription('Diameter of the circle'),
),
).exportAs('CropConfigCircle'),
new CG.obj(
new CG.prop('shape', new CG.const('rectangle').setTitle('Shape').setDescription('Rectangular cropping area')),
new CG.prop(
'width',
new CG.num().optional({ default: 250 }).setTitle('Width').setDescription('Width of the rectangle'),
),
new CG.prop(
'height',
new CG.num().optional({ default: 250 }).setTitle('Height').setDescription('Height of the rectangle'),
),
).exportAs('CropConfigRect'),
)
.setUnionType('discriminated')
.optional({ default: { shape: 'circle', diameter: 250 } })
.exportAs('CropConfig'),
),
)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice suggestion! 👍 I will take a look at it, and see how we can display this in studio designer as well. For now we have a selector for shape and 2 input fields for width and height.
const handleWheel = useCallback( | ||
(e: WheelEvent) => { | ||
e.preventDefault(); | ||
onZoomChange(zoom - e.deltaY * 0.001); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, I found this to be less useful than I hoped. The first scroll 'click' zooms in waay too much, but later clicks zoom in less and less. I didn't need all that finesse when looking at huge pixels, but I wanted more finesse at first.. 😅
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, you mean it should be less zoom sensitive in the first part of the zoom scale when working with huge pixels?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
haha, I see what you mean 😆 Here I scroll:
zoom.mp4
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup, exactly!
const imageFile = new File([blob], fileName, { type: 'image/png' }); | ||
saveImage(imageFile); | ||
setValidationErrors(null); | ||
}, 'image/png'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, nice! Looks very easy to add an option to upload as jpeg as well (seems supported in all major browsers). I see lots of apps have added limitations in the allowedContentTypes
property for the data element type in applicationmetadata
, so maybe we should check that and upload as image/jpeg
if image/png
is not allowed by the app. Aaaand throw an error early if none of the supported image types are allowed, I guess.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point, will look into that 🙂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added this utility.
If allowedContentTypes
is not set - use png.
If allowedContentTypes
is set - check if png is allowed, if not, check if jpeg is allowed.
If neither png or jpeg is allowed, check if there is other image types that is allowed.
If none above, throw an error when the component is rendered.
export const getAllowedImageType = ({ baseComponentId, dataTypes }: AllowedImageTypeParams) => {
const dataType = dataTypes.find((datatype) => datatype.id === baseComponentId);
const defaultTypes = ['image/png', 'image/jpeg'];
const allowed = dataType?.allowedContentTypes ?? [];
if (allowed.length === 0) {
return defaultTypes[0];
}
const preferred = defaultTypes.find((type) => allowed.includes(type));
if (preferred) {
return preferred;
}
const firstImageAllowed = allowed.find((type) => type.startsWith('image/'));
if (firstImageAllowed) {
return firstImageAllowed;
}
throw new Error(`allowedContentTypes is configured for dataType '${baseComponentId}', but contains no image types.`);
};
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had to go back one this. To only save as PNG, but have kept validation to check if allowedContentTypes
is defined and if so check if image/png
exists there, if it doesnt exist then an error message is thrown.
The reason for this is that JPEG doesn't support transparency well, so a circle will be wrapped with a black square.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That makes sense! 🙌
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Solid work! Few comments to be considered.
} | ||
|
||
.previewBackground { | ||
background-color: #f4f5f6; /* Following does not exist in v1: var(--ds-color-neutral-background-subtle); */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we use a different color here? If this color already exists elsewhere, we should align with that and reference a shared CSS variable/design token. If it’s specific to this component only, consider introducing a v1 component-scoped token instead of hard-coding the value.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We will look for a similar token instead :)
@@ -0,0 +1,161 @@ | |||
import React, { useCallback, useEffect } from 'react'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great work on this component! This isn’t a blocker, but it currently mixes concerns, Single Responsibility Principle. It handles rendering, event handling, drag-and-drop, zoom, keyboard navigation, and canvas drawing in one place. Consider splitting these out to improve readability and testability.
An alternative is to split responsibilities into separate modules. I’m not suggesting we scatter logic—maintaining cohesion matters—but we can keep everything under the component’s folder and factor it into focused files.
ImageCanvas.tsx
├── hooks/
│ ├──hooks.something.ts
├── utils/
│ ├── SomeUtiLS
The ImageCanvas could look like the example below. We’re not writing unit tests with Vitest or Jest right now, so most coverage sits in Cypress so its easy to test as is.
export function ImageCanvas({
imageRef,
zoom,
position,
cropArea,
baseComponentId,
onPositionChange,
onZoomChange,
canvasRef,
}: ImageCanvasProps) {
const { storedImage, imageUrl } = useImageFile(baseComponentId);
// extract canvas and zoom into custom hooks.
useCanvasDraw({ canvasRef, imageRef, zoom, position, cropArea });
useZoomInteraction(canvasRef, zoom, onZoomChange);
const { handlePointerDown } = useDragInteraction(canvasRef, position, onPositionChange);
const { handleKeyDown } = useKeyboardNavigation(position, onPositionChange);
// Ref. previous comment, the code below seperated the preview to explicit component.
if (storedImage) {
return <UploadedImagePreview storedImage={storedImage} imageUrl={imageUrl} />;
}
return (
<canvas
onPointerDown={handlePointerDown}
onKeyDown={handleKeyDown}
tabIndex={0}
ref={canvasRef}
height={CANVAS_HEIGHT}
width={CANVAS_WIDTH}
className={classes.canvas}
aria-label='Image cropping area' // <-- Ref. previous comment, this should support multiple langues.
/>
);
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very good feedback, will do that.
export const imagePlacement = ({ canvas, img, zoom, position }: ImagePlacementParams) => { | ||
const scaledWidth = img.width * zoom; | ||
const scaledHeight = img.height * zoom; | ||
const imgX = (canvas.width - scaledWidth) / 2 + position.x; | ||
const imgY = (canvas.height - scaledHeight) / 2 + position.y; | ||
|
||
return { imgX, imgY, scaledWidth, scaledHeight }; | ||
}; | ||
|
||
type CropAreaPlacementParams = { canvas: HTMLCanvasElement; cropArea: CropArea }; | ||
type CropAreaPlacement = { cropAreaX: number; cropAreaY: number }; | ||
|
||
export const cropAreaPlacement = ({ canvas, cropArea }: CropAreaPlacementParams): CropAreaPlacement => { | ||
const cropAreaX = (canvas.width - cropArea.width) / 2; | ||
const cropAreaY = (canvas.height - cropArea.height) / 2; | ||
return { cropAreaX, cropAreaY }; | ||
}; | ||
|
||
interface DrawCropAreaParams { | ||
ctx: CanvasRenderingContext2D; | ||
cropArea: CropArea; | ||
x?: number; | ||
y?: number; | ||
} | ||
|
||
export function drawCropArea({ ctx, x = 0, y = 0, cropArea }: DrawCropAreaParams) { | ||
const { width, height, type } = cropArea; | ||
ctx.beginPath(); | ||
if (type === CropForm.Circle) { | ||
ctx.arc(x + width / 2, y + height / 2, width / 2, 0, Math.PI * 2); | ||
} else { | ||
ctx.rect(x, y, width, height); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we extract the magic numbers into named constants? I notice the value 2 appears multiple times—do these instances represent the same domain rule, or are they unrelated?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The divider of 2
is often used to find the center of the crop area. Can look into it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
♻️ Duplicate comments (10)
src/layout/ImageUpload/ImageCropper.tsx (4)
94-116
: Memory leak: Image and FileReader not cleaned upThe
Image
andFileReader
created inhandleFileUpload
are not cleaned up if the component unmounts or a new file is uploaded before the previous load completes. This can cause memory leaks and stale callbacks.Track loading resources in refs and clean them up:
+ const loadingImageRef = useRef<HTMLImageElement | null>(null); + const fileReaderRef = useRef<FileReader | null>(null); + const handleFileUpload = (file: File) => { const validationErrors = validateFile(file); setValidationErrors(validationErrors); if (validationErrors.length > 0) { return; } + // Clean up previous loading resources + if (loadingImageRef.current) { + loadingImageRef.current.onload = null; + loadingImageRef.current.src = ''; + loadingImageRef.current = null; + } + if (fileReaderRef.current) { + fileReaderRef.current.abort(); + fileReaderRef.current = null; + } + const reader = new FileReader(); + fileReaderRef.current = reader; reader.onload = (event) => { const result = event.target?.result; if (typeof result === 'string') { const img = new Image(); + loadingImageRef.current = img; img.id = file.name; imageTypeRef.current = file.type; img.onload = () => { + if (loadingImageRef.current === img) { updateImageState({ minZoom: calculateMinZoom({ img, cropArea }), img }); + loadingImageRef.current = null; + } }; img.src = result; } }; reader.readAsDataURL(file); }; + + // Clean up on unmount + useEffect(() => { + return () => { + if (loadingImageRef.current) { + loadingImageRef.current.onload = null; + loadingImageRef.current.src = ''; + } + if (fileReaderRef.current) { + fileReaderRef.current.abort(); + } + }; + }, []);
138-148
: Handle toBlob failures with user feedbackThe
toBlob
callback silently fails whenblob
is null, providing no user feedback. Consider adding error handling and notifying the user.cropCanvas.toBlob((blob) => { if (!blob) { + console.error('Failed to create image blob from canvas'); + setValidationErrors(['image_upload_component.error_save_failed']); return; } const newFileName = getNewFileName({ fileName: img.id }); const imageFile = new File([blob], newFileName, { type: IMAGE_TYPE }); saveImage(imageFile); setValidationErrors(null); }, IMAGE_TYPE);Add the error key to your language files:
'image_upload_component.error_save_failed': 'Failed to save the cropped image. Please try again.'
94-116
: Clean up FileReader and Image to prevent memory leaks.The FileReader and Image instances are not cleaned up if the component unmounts during loading or if a new file is uploaded before the previous one completes. This causes unnecessary resource consumption and stale callbacks.
Track loading state and clean up on unmount or new uploads:
+ const loadingImageRef = useRef<HTMLImageElement | null>(null); + const fileReaderRef = useRef<FileReader | null>(null); + const handleFileUpload = (file: File) => { const validationErrors = validateFile(file); setValidationErrors(validationErrors); if (validationErrors.length > 0) { return; } + // Clean up previous loading operations + if (loadingImageRef.current) { + loadingImageRef.current.onload = null; + loadingImageRef.current.src = ''; + loadingImageRef.current = null; + } + if (fileReaderRef.current) { + fileReaderRef.current.onload = null; + fileReaderRef.current = null; + } + const reader = new FileReader(); + fileReaderRef.current = reader; reader.onload = (event) => { const result = event.target?.result; if (typeof result === 'string') { const img = new Image(); + loadingImageRef.current = img; img.id = file.name; imageTypeRef.current = file.type; img.onload = () => { + if (loadingImageRef.current === img) { updateImageState({ minZoom: calculateMinZoom({ img, cropArea }), img }); + loadingImageRef.current = null; + } }; img.src = result; } }; reader.readAsDataURL(file); };Add cleanup on unmount:
useEffect(() => { return () => { if (loadingImageRef.current) { loadingImageRef.current.onload = null; loadingImageRef.current.src = ''; } if (fileReaderRef.current) { fileReaderRef.current.onload = null; } }; }, []);
138-148
: Add error handling and user feedback for toBlob failures.The toBlob callback doesn't handle failures beyond an early return. Users won't know if the crop operation failed.
Add error logging and consider user feedback:
cropCanvas.toBlob((blob) => { if (!blob) { + console.error('Failed to create image blob during crop operation'); + // TODO: Consider adding user-visible error feedback (toast/alert) return; } const newFileName = getNewFileName({ fileName: img.id }); const imageFile = new File([blob], newFileName, { type: IMAGE_TYPE }); saveImage(imageFile); setValidationErrors(null); }, IMAGE_TYPE);src/layout/ImageUpload/imageUploadUtils.ts (6)
41-52
: Use naturalWidth/naturalHeight for accurate pixel calculationsLines 42-43 use
image.width
andimage.height
, which are CSS pixels and may not reflect the actual image dimensions. For pixel-accurate canvas calculations, usenaturalWidth
andnaturalHeight
. Also guard against zero dimensions to preventNaN
orInfinity
.Apply this diff:
export function constrainToArea({ image, zoom, position, cropArea }: ConstrainToAreaParams): Position { - const scaledWidth = image.width * zoom; - const scaledHeight = image.height * zoom; + const iw = image.naturalWidth || image.width || 1; + const ih = image.naturalHeight || image.height || 1; + const scaledWidth = iw * zoom; + const scaledHeight = ih * zoom; const clampX = scaledWidth > cropArea.width ? (scaledWidth - cropArea.width) / 2 : 0; const clampY = scaledHeight > cropArea.height ? (scaledHeight - cropArea.height) / 2 : 0; const newX = Math.max(-clampX, Math.min(position.x, clampX)); const newY = Math.max(-clampY, Math.min(position.y, clampY)); return { x: newX, y: newY }; }
61-68
: Use naturalWidth/naturalHeight for accurate pixel calculationsLines 62-63 use
img.width
andimg.height
. For pixel-accurate canvas calculations, usenaturalWidth
andnaturalHeight
.Apply this diff:
export const imagePlacement = ({ canvas, img, zoom, position }: ImagePlacementParams) => { - const scaledWidth = img.width * zoom; - const scaledHeight = img.height * zoom; + const iw = img.naturalWidth || img.width || 1; + const ih = img.naturalHeight || img.height || 1; + const scaledWidth = iw * zoom; + const scaledHeight = ih * zoom; const imgX = (canvas.width - scaledWidth) / 2 + position.x; const imgY = (canvas.height - scaledHeight) / 2 + position.y; return { imgX, imgY, scaledWidth, scaledHeight }; };
105-122
: Guard zoom calculations against invalid values
getLogValues
doesn't validate thatminZoom
andmaxZoom
are positive.Math.log
of non-positive values returns-Infinity
orNaN
, breaking the zoom calculations. Additionally,logToNormalZoom
should clamp its result to[0, 100]
to keep the slider stable.Apply this diff:
function getLogValues({ minZoom, maxZoom }: ZoomParams): { logScale: number; logMin: number } { + if (minZoom <= 0 || maxZoom <= 0) { + throw new Error('minZoom and maxZoom must be positive'); + } + if (minZoom >= maxZoom) { + throw new Error('minZoom must be less than maxZoom'); + } const logMin = Math.log(minZoom); const logMax = Math.log(maxZoom); return { logScale: (logMax - logMin) / 100, logMin }; } export function normalToLogZoom({ value, minZoom, maxZoom }: CalculateZoomParams): number { const { logScale, logMin } = getLogValues({ minZoom, maxZoom }); - return Math.exp(logMin + logScale * value); + const result = Math.exp(logMin + logScale * value); + return Math.max(minZoom, Math.min(result, maxZoom)); } export function logToNormalZoom({ value, minZoom, maxZoom }: CalculateZoomParams): number { const { logScale, logMin } = getLogValues({ minZoom, maxZoom }); if (logScale === 0) { return 0; } - return (Math.log(value) - logMin) / logScale; + const safeValue = Math.max(value, Number.EPSILON); + const normalized = (Math.log(safeValue) - logMin) / logScale; + return Math.max(0, Math.min(normalized, 100)); }
125-126
: Use naturalWidth/naturalHeight and guard against zero dimensions
calculateMinZoom
usesimg.width
andimg.height
instead of natural dimensions. It also doesn't guard against zero dimensions, which would produceInfinity
.Apply this diff:
-export const calculateMinZoom = ({ img, cropArea }: CalculateMinZoomParams) => - Math.max(cropArea.width / img.width, cropArea.height / img.height); +export const calculateMinZoom = ({ img, cropArea }: CalculateMinZoomParams) => { + const iw = img.naturalWidth || img.width || 1; + const ih = img.naturalHeight || img.height || 1; + return Math.max(cropArea.width / iw, cropArea.height / ih); +};
41-52
: Use naturalWidth/naturalHeight for pixel-accurate calculations.Lines 42-43, 62-63, and 126 use
image.width
/image.height
instead ofimage.naturalWidth
/image.naturalHeight
. Thewidth
/height
properties reflect CSS sizing, which can differ from the actual image dimensions, leading to incorrect crop calculations.Apply this diff to use natural dimensions with zero guards:
export function constrainToArea({ image, zoom, position, cropArea }: ConstrainToAreaParams): Position { - const scaledWidth = image.width * zoom; - const scaledHeight = image.height * zoom; + const iw = Math.max(1, image.naturalWidth || image.width); + const ih = Math.max(1, image.naturalHeight || image.height); + const scaledWidth = iw * zoom; + const scaledHeight = ih * zoom; const clampX = scaledWidth > cropArea.width ? (scaledWidth - cropArea.width) / 2 : 0; const clampY = scaledHeight > cropArea.height ? (scaledHeight - cropArea.height) / 2 : 0; const newX = Math.max(-clampX, Math.min(position.x, clampX)); const newY = Math.max(-clampY, Math.min(position.y, clampY)); return { x: newX, y: newY }; }export const imagePlacement = ({ canvas, img, zoom, position }: ImagePlacementParams) => { - const scaledWidth = img.width * zoom; - const scaledHeight = img.height * zoom; + const iw = Math.max(1, img.naturalWidth || img.width); + const ih = Math.max(1, img.naturalHeight || img.height); + const scaledWidth = iw * zoom; + const scaledHeight = ih * zoom; const imgX = (canvas.width - scaledWidth) / 2 + position.x; const imgY = (canvas.height - scaledHeight) / 2 + position.y; return { imgX, imgY, scaledWidth, scaledHeight }; };-export const calculateMinZoom = ({ img, cropArea }: CalculateMinZoomParams) => - Math.max(cropArea.width / img.width, cropArea.height / img.height); +export const calculateMinZoom = ({ img, cropArea }: CalculateMinZoomParams) => { + const iw = Math.max(1, img.naturalWidth || img.width); + const ih = Math.max(1, img.naturalHeight || img.height); + return Math.max(cropArea.width / iw, cropArea.height / ih); +};Also applies to: 61-68, 125-126
105-122
: Guard zoom math against invalid inputs and NaN/Infinity.
Math.log
with non-positiveminZoom
,maxZoom
, orvalue
produces-Infinity
/NaN
. The normalized output should also be clamped to[0, 100]
to keep the slider stable.Apply these guards:
function getLogValues({ minZoom, maxZoom }: ZoomParams): { logScale: number; logMin: number } { + if (minZoom <= 0 || maxZoom <= 0) { + throw new Error('minZoom and maxZoom must be positive'); + } + if (minZoom === maxZoom) { + return { logScale: 0, logMin: Math.log(minZoom) }; + } const logMin = Math.log(minZoom); const logMax = Math.log(maxZoom); return { logScale: (logMax - logMin) / 100, logMin }; }export function normalToLogZoom({ value, minZoom, maxZoom }: CalculateZoomParams): number { const { logScale, logMin } = getLogValues({ minZoom, maxZoom }); - return Math.exp(logMin + logScale * value); + const result = Math.exp(logMin + logScale * value); + return Math.min(maxZoom, Math.max(minZoom, result)); }export function logToNormalZoom({ value, minZoom, maxZoom }: CalculateZoomParams): number { const { logScale, logMin } = getLogValues({ minZoom, maxZoom }); if (logScale === 0) { - return 0; + return 50; // Return middle of slider when min equals max } - return (Math.log(value) - logMin) / logScale; + const safeValue = Math.max(Number.EPSILON, value); + const normalized = (Math.log(safeValue) - logMin) / logScale; + return Math.min(100, Math.max(0, normalized)); }
🧹 Nitpick comments (1)
src/layout/ImageUpload/imageUploadUtils.ts (1)
45-46
: Consider extracting centering divisor as a named constant.The value
2
appears multiple times (lines 45-46, 64-65, 74-75, 90) for centering and radius calculations. While the intent is clear in context, extracting it could improve readability per the earlier review feedback.Based on learnings
Example:
const HALF = 2; // Then use HALF instead of literal 2 in division operations const clampX = scaledWidth > cropArea.width ? (scaledWidth - cropArea.width) / HALF : 0; const imgX = (canvas.width - scaledWidth) / HALF + position.x; const cropAreaX = (canvas.width - cropArea.width) / HALF; ctx.arc(x + width / HALF, y + height / HALF, width / HALF, 0, Math.PI * 2);Note: This is a minor readability enhancement and may be considered pedantic.
Also applies to: 64-65, 74-75, 90-90
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (7)
src/layout/FileUpload/utils/useFileUploaderDataBindingsValidation.ts
(1 hunks)src/layout/ImageUpload/ImageCropper.tsx
(1 hunks)src/layout/ImageUpload/ImageUploadComponent.tsx
(1 hunks)src/layout/ImageUpload/config.ts
(1 hunks)src/layout/ImageUpload/imageUploadUtils.test.tsx
(1 hunks)src/layout/ImageUpload/imageUploadUtils.ts
(1 hunks)src/layout/ImageUpload/index.tsx
(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
- src/layout/ImageUpload/config.ts
- src/layout/ImageUpload/index.tsx
- src/layout/ImageUpload/ImageUploadComponent.tsx
- src/layout/ImageUpload/imageUploadUtils.test.tsx
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
**/*.{ts,tsx}
: Avoid usingany
and unnecessary type casts (as Type
) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options viaqueryOptions
Files:
src/layout/FileUpload/utils/useFileUploaderDataBindingsValidation.ts
src/layout/ImageUpload/ImageCropper.tsx
src/layout/ImageUpload/imageUploadUtils.ts
🧬 Code graph analysis (2)
src/layout/ImageUpload/ImageCropper.tsx (6)
src/layout/ImageUpload/imageUploadUtils.ts (10)
CropArea
(15-15)Position
(6-6)calculateMinZoom
(125-126)constrainToArea
(41-52)imagePlacement
(61-68)validateFile
(128-151)cropAreaPlacement
(73-77)drawCropArea
(86-94)getNewFileName
(170-173)IMAGE_TYPE
(4-4)src/layout/ImageUpload/useImageFile.tsx (1)
useImageFile
(17-52)src/layout/ImageUpload/ImageDropzone.tsx (1)
ImageDropzone
(18-58)src/layout/ImageUpload/ImageCanvas.tsx (1)
ImageCanvas
(25-161)src/layout/ImageUpload/ImageControllers.tsx (1)
ImageControllers
(26-157)src/features/language/Lang.tsx (1)
Lang
(15-23)
src/layout/ImageUpload/imageUploadUtils.ts (1)
src/types/shared.ts (1)
IDataType
(56-68)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
- GitHub Check: Analyze (javascript)
- GitHub Check: Type-checks, eslint, unit tests and SonarCloud
- GitHub Check: Install
🔇 Additional comments (2)
src/layout/FileUpload/utils/useFileUploaderDataBindingsValidation.ts (1)
10-10
: LGTM! Generic constraint correctly extended for ImageUpload support.Add unit tests covering useFileUploaderDataBindingsValidation with ImageUpload bindings (simpleBinding/list) to ensure the validation paths function as expected.
src/layout/ImageUpload/imageUploadUtils.ts (1)
1-173
: LGTM: Well-structured utility module.The module provides a clean, well-organized API for image cropping calculations. The comment on Line 3 explaining the PNG choice is helpful. Type definitions are clear and the separation of concerns is good.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
♻️ Duplicate comments (3)
src/layout/ImageUpload/ImageControllers.tsx (2)
106-115
: Improve reset button accessibility with aria-label.The icon-only reset button uses a
title
attribute on the icon (line 112), which provides a tooltip but may not be reliably announced by all screen readers. Adding anaria-label
to the Button ensures consistent accessibility.Apply this diff to improve accessibility:
<Button onClick={onReset} variant='tertiary' icon={true} + aria-label={langAsString('image_upload_component.reset')} > - <ArrowUndoIcon - title={langAsString('image_upload_component.reset')} - className={classes.resetButton} - /> + <ArrowUndoIcon className={classes.resetButton} /> </Button>
45-53
: Critical zoom bug: slider range (0–100) doesn't match utility expectations (0–1).The slider uses a 0–100 range (lines 96-105), but
normalToLogZoom
expects a normalized 0–1 value. Similarly,logToNormalZoom
returns a 0–1 value that must be multiplied by 100 for the slider. This mismatch breaks zoom functionality.Apply this diff to fix the zoom normalization:
const handleSliderZoom = (e: React.ChangeEvent<HTMLInputElement>) => { const logarithmicZoomValue = normalToLogZoom({ - value: Number.parseFloat(e.target.value), + value: Number.parseFloat(e.target.value) / 100, minZoom, maxZoom, }); updateZoom(logarithmicZoomValue); };And update the slider value prop:
<input id={zoomId} type='range' min='0' max='100' step='0.5' - value={logToNormalZoom({ value: zoom, minZoom, maxZoom })} + value={logToNormalZoom({ value: zoom, minZoom, maxZoom }) * 100} onChange={handleSliderZoom} className={classes.zoomSlider} />src/layout/ImageUpload/ImageCanvas.tsx (1)
69-71
: Redraw when the image finishes loadingIf the image isn’t complete at first render, the canvas never updates. Add an onload listener to trigger draw() once the image loads.
useEffect(() => { draw(); }, [draw]); + + // Redraw once the image finishes loading + useEffect(() => { + const img = imageRef.current; + if (!img) return; + const onLoad = () => draw(); + if (!img.complete) { + img.addEventListener('load', onLoad, { once: true }); + } + return () => img.removeEventListener('load', onLoad); + }, [imageRef, draw]);
🧹 Nitpick comments (1)
src/layout/ImageUpload/ImageCanvas.tsx (1)
115-128
: Clamp keyboard panning to prevent exposing empty areasArrow-key movement should respect the same constraints as drag.
const handleKeyDown = (e: React.KeyboardEvent<HTMLCanvasElement>) => { const moveAmount = 10; - const keyMap: Record<string, () => void> = { - ArrowUp: () => onPositionChange({ ...position, y: position.y - moveAmount }), - ArrowDown: () => onPositionChange({ ...position, y: position.y + moveAmount }), - ArrowLeft: () => onPositionChange({ ...position, x: position.x - moveAmount }), - ArrowRight: () => onPositionChange({ ...position, x: position.x + moveAmount }), - }; + const move = (dx: number, dy: number) => { + const next = { x: position.x + dx, y: position.y + dy }; + const img = imageRef.current; + const c = canvasRef.current; + onPositionChange(img && c ? constrainToArea({ image: img, zoom, position: next, cropArea, canvas: c }) : next); + }; + const keyMap: Record<string, () => void> = { + ArrowUp: () => move(0, -moveAmount), + ArrowDown: () => move(0, moveAmount), + ArrowLeft: () => move(-moveAmount, 0), + ArrowRight: () => move(moveAmount, 0), + }; if (keyMap[e.key]) { e.preventDefault(); keyMap[e.key](); } };
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (8)
src/language/texts/en.ts
(1 hunks)src/language/texts/nb.ts
(2 hunks)src/language/texts/nn.ts
(2 hunks)src/layout/ImageUpload/ImageCanvas.tsx
(1 hunks)src/layout/ImageUpload/ImageControllers.module.css
(1 hunks)src/layout/ImageUpload/ImageControllers.tsx
(1 hunks)src/layout/ImageUpload/ImageDropzone.module.css
(1 hunks)src/layout/ImageUpload/ImageDropzone.tsx
(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (5)
- src/layout/ImageUpload/ImageControllers.module.css
- src/language/texts/nb.ts
- src/language/texts/en.ts
- src/layout/ImageUpload/ImageDropzone.module.css
- src/layout/ImageUpload/ImageDropzone.tsx
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
**/*.{ts,tsx}
: Avoid usingany
and unnecessary type casts (as Type
) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options viaqueryOptions
Files:
src/layout/ImageUpload/ImageControllers.tsx
src/language/texts/nn.ts
src/layout/ImageUpload/ImageCanvas.tsx
🧬 Code graph analysis (2)
src/layout/ImageUpload/ImageControllers.tsx (4)
src/features/attachments/index.ts (1)
UploadedAttachment
(20-20)src/features/language/useLanguage.ts (1)
useLanguage
(90-93)src/layout/ImageUpload/imageUploadUtils.ts (3)
normalToLogZoom
(111-114)isAnimationFile
(153-156)logToNormalZoom
(116-122)src/features/language/Lang.tsx (1)
Lang
(15-23)
src/layout/ImageUpload/ImageCanvas.tsx (3)
src/layout/ImageUpload/imageUploadUtils.ts (5)
Position
(6-6)CropArea
(15-15)imagePlacement
(61-68)cropAreaPlacement
(73-77)drawCropArea
(86-94)src/layout/ImageUpload/useImageFile.tsx (1)
useImageFile
(17-52)src/features/language/useLanguage.ts (1)
useLanguage
(90-93)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
- GitHub Check: Install
- GitHub Check: Analyze (javascript)
- GitHub Check: Type-checks, eslint, unit tests and SonarCloud
🔇 Additional comments (1)
src/layout/ImageUpload/ImageControllers.tsx (1)
1-159
: LGTM overall structure and implementation.The component structure is well-organized with:
- Proper use of React hooks (useId, useRef, useLanguage)
- Clear separation of concerns (handlers, conditional rendering)
- Good keyboard accessibility for file selection
- Appropriate prop types and TypeScript usage
Once the zoom normalization bug and reset button accessibility are addressed, this component will be production-ready.
const CANVAS_HEIGHT = 320; | ||
const CANVAS_WIDTH = 1000; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Clamp zoom (avoid negative/zero) and normalize wheel delta; consider tuning sensitivity
Current logic can drive zoom ≤ 0 and feels uneven. Clamp to a sane range and normalize deltaMode for consistent behavior.
const CANVAS_HEIGHT = 320;
const CANVAS_WIDTH = 1000;
+const MIN_ZOOM = 0.1;
+const MAX_ZOOM = 8;
+const ZOOM_SENSITIVITY = 0.001;
const handleWheel = useCallback(
(e: WheelEvent) => {
e.preventDefault();
- onZoomChange(zoom - e.deltaY * 0.001);
+ // Normalize wheel delta across devices (pixels/lines/pages)
+ const normalizedDelta =
+ e.deltaMode === 1 ? e.deltaY * 16 : e.deltaMode === 2 ? e.deltaY * 800 : e.deltaY;
+ const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom - normalizedDelta * ZOOM_SENSITIVITY));
+ onZoomChange(next);
},
[zoom, onZoomChange],
);
Note: You can also switch to multiplicative zoom for finer control at low zoom:
next = clamp(zoom * Math.pow(1.0015, -normalizedDelta), MIN_ZOOM, MAX_ZOOM).
Also applies to: 97-103
🤖 Prompt for AI Agents
In src/layout/ImageUpload/ImageCanvas.tsx around lines 22-24 (and the wheel
handler at 97-103), add MIN_ZOOM and MAX_ZOOM constants (e.g., 0.1 and 10) and
replace the current wheel zoom math with a normalized delta and multiplicative
clamp: compute normalizedDelta by mapping event.deltaY according to
event.deltaMode (DOM_DELTA_PIXEL=0 use as-is, DOM_DELTA_LINE multiply by ~16,
DOM_DELTA_PAGE multiply by window.innerHeight), then apply nextZoom = clamp(zoom
* Math.pow(1.0015, -normalizedDelta * SENSITIVITY), MIN_ZOOM, MAX_ZOOM); ensure
SENSITIVITY is a configurable small number to tune feel, and always set state
with the clamped nextZoom so zoom never becomes ≤0.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
♻️ Duplicate comments (2)
src/layout/ImageUpload/ImageCropper.tsx (2)
198-212
: Non-null assertion fails when only storedImage exists.Line 200 uses
imageTypeRef.current!
, butimageTypeRef.current
is only set inhandleFileUpload
(line 108). When the component mounts with an existingstoredImage
but no new upload:
imageRef.current
isnull
storedImage
existsimageTypeRef.current
isnull
- The condition
(imageRef.current || storedImage)
passesImageControllers
receivesnull
asimageType
, violating its type contractUse optional chaining with a safe fallback:
{(imageRef.current || storedImage) && ( <ImageControllers - imageType={imageTypeRef.current!} + imageType={imageTypeRef.current ?? storedImage?.data?.contentType ?? IMAGE_TYPE} readOnly={readOnly} zoom={zoom} zoomLimits={{ minZoom: minAllowedZoom, maxZoom: MAX_ZOOM }} storedImage={storedImage} updateZoom={handleZoomChange} onSave={handleSave} onDelete={handleDeleteImage} onCancel={handleCancel} onFileUploaded={handleFileUpload} onReset={() => updateImageState({})} /> )}This ensures
imageType
always receives a valid string, falling back to the stored image's content type or the defaultIMAGE_TYPE
constant.
94-116
: Address memory leak and race condition in file upload.The
FileReader
andImage
objects created inhandleFileUpload
are not cleaned up. This creates two issues:
- Memory leak: If the component unmounts while an image is loading, the
onload
handlers continue to hold references and eventually fire.- Race condition: If a user uploads a new file before the previous one finishes loading, both
onload
handlers will fire and callupdateImageState
, potentially with the wrong image.Consider tracking the loading state and cleaning up:
+ const loadingImageRef = useRef<HTMLImageElement | null>(null); + const loadingReaderRef = useRef<FileReader | null>(null); + const handleFileUpload = (file: File) => { const validationErrors = validateFile(file); setValidationErrors(validationErrors); if (validationErrors.length > 0) { return; } + // Clean up any previous loading + if (loadingImageRef.current) { + loadingImageRef.current.onload = null; + loadingImageRef.current.src = ''; + } + if (loadingReaderRef.current) { + loadingReaderRef.current.onload = null; + } const reader = new FileReader(); + loadingReaderRef.current = reader; reader.onload = (event) => { const result = event.target?.result; if (typeof result === 'string') { const img = new Image(); + loadingImageRef.current = img; img.id = file.name; imageTypeRef.current = file.type; img.onload = () => { + // Only update if this is still the current loading image + if (loadingImageRef.current === img) { updateImageState({ minZoom: calculateMinZoom({ img, cropArea }), img }); + loadingImageRef.current = null; + } }; img.src = result; } }; reader.readAsDataURL(file); }; + + // Clean up on unmount + useEffect(() => { + return () => { + if (loadingImageRef.current) { + loadingImageRef.current.onload = null; + loadingImageRef.current.src = ''; + } + if (loadingReaderRef.current) { + loadingReaderRef.current.onload = null; + } + }; + }, []);Don't forget to import
useEffect
:-import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react';
🧹 Nitpick comments (4)
src/layout/ImageUpload/ImageCanvas/hooks/useKeyboardNavigation.tsx (1)
11-31
: Consider clamping and accelerated steps (Shift+Arrow).Ensure position is clamped in this layer or upstream to avoid moving image outside allowed bounds. Optionally support Shift+Arrow for larger step (e.g., 50px) to improve keyboard UX.
src/layout/ImageUpload/ImageCanvas/ImageCanvas.module.css (1)
1-12
: Add visible focus style for keyboard users.The canvas is focusable; provide a clear focus ring.
.canvas { display: block; position: relative; left: 50%; transform: translateX(-50%); cursor: grab; touch-action: none; } .canvas:active { cursor: grabbing; } + +.canvas:focus-visible { + outline: 2px solid var(--fds-semantic-border-focus, #1a73e8); + outline-offset: 2px; +}src/layout/ImageUpload/ImageCanvas/ImageCanvas.tsx (1)
26-35
: E2E under feature flag: gate tests instead of changing CI app.To avoid breaking other PRs, gate Cypress tests on the imageUpload flag:
- Make tests conditional: read Cypress.env('imageUpload') or app runtime flag, and skip when disabled.
- In GH Actions for this PR, set the env to enable the flag only for these tests.
- Alternatively, in the test, programmatically enable the flag via app config/route param/local storage if supported by the app.
This keeps CI stable while validating the feature where relevant.
src/layout/ImageUpload/ImageCropper.tsx (1)
138-148
: Add error handling for failed blob creation.When
toBlob
fails and returnsnull
, the function silently returns without saving or notifying the user. This can leave users confused about whether their image was saved.Consider adding error handling:
cropCanvas.toBlob((blob) => { if (!blob) { + console.error('Failed to create image blob'); + setValidationErrors(['image_upload_component.error_save_failed']); return; } const newFileName = getNewFileName({ fileName: img.id }); const imageFile = new File([blob], newFileName, { type: IMAGE_TYPE }); saveImage(imageFile); setValidationErrors(null); }, IMAGE_TYPE);Add the corresponding translation key to your language files:
image_upload_component: { error_save_failed: 'Failed to save the cropped image. Please try again.', }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (9)
src/layout/ImageUpload/ImageCanvas/ImageCanvas.module.css
(1 hunks)src/layout/ImageUpload/ImageCanvas/ImageCanvas.tsx
(1 hunks)src/layout/ImageUpload/ImageCanvas/ImagePreview.module.css
(1 hunks)src/layout/ImageUpload/ImageCanvas/ImagePreview.tsx
(1 hunks)src/layout/ImageUpload/ImageCanvas/hooks/useCanvasDraw.tsx
(1 hunks)src/layout/ImageUpload/ImageCanvas/hooks/useDragInteraction.tsx
(1 hunks)src/layout/ImageUpload/ImageCanvas/hooks/useKeyboardNavigation.tsx
(1 hunks)src/layout/ImageUpload/ImageCanvas/hooks/useZoomInteraction.tsx
(1 hunks)src/layout/ImageUpload/ImageCropper.tsx
(1 hunks)
✅ Files skipped from review due to trivial changes (1)
- src/layout/ImageUpload/ImageCanvas/ImagePreview.module.css
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
**/*.{ts,tsx}
: Avoid usingany
and unnecessary type casts (as Type
) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options viaqueryOptions
Files:
src/layout/ImageUpload/ImageCanvas/ImagePreview.tsx
src/layout/ImageUpload/ImageCanvas/hooks/useKeyboardNavigation.tsx
src/layout/ImageUpload/ImageCanvas/ImageCanvas.tsx
src/layout/ImageUpload/ImageCanvas/hooks/useDragInteraction.tsx
src/layout/ImageUpload/ImageCanvas/hooks/useCanvasDraw.tsx
src/layout/ImageUpload/ImageCanvas/hooks/useZoomInteraction.tsx
src/layout/ImageUpload/ImageCropper.tsx
**/*.module.css
📄 CodeRabbit inference engine (CLAUDE.md)
Use CSS Modules for component styling and follow existing patterns in
*.module.css
files
Files:
src/layout/ImageUpload/ImageCanvas/ImageCanvas.module.css
🧬 Code graph analysis (6)
src/layout/ImageUpload/ImageCanvas/ImagePreview.tsx (2)
src/features/attachments/index.ts (1)
UploadedAttachment
(20-20)src/features/language/useLanguage.ts (1)
useLanguage
(90-93)
src/layout/ImageUpload/ImageCanvas/hooks/useKeyboardNavigation.tsx (1)
src/layout/ImageUpload/imageUploadUtils.ts (1)
Position
(6-6)
src/layout/ImageUpload/ImageCanvas/ImageCanvas.tsx (8)
src/layout/ImageUpload/imageUploadUtils.ts (2)
Position
(6-6)CropArea
(15-15)src/layout/ImageUpload/useImageFile.tsx (1)
useImageFile
(17-52)src/features/language/useLanguage.ts (1)
useLanguage
(90-93)src/layout/ImageUpload/ImageCanvas/hooks/useCanvasDraw.tsx (1)
useCanvasDraw
(20-51)src/layout/ImageUpload/ImageCanvas/hooks/useZoomInteraction.tsx (1)
useZoomInteraction
(10-28)src/layout/ImageUpload/ImageCanvas/hooks/useDragInteraction.tsx (1)
useDragInteraction
(11-40)src/layout/ImageUpload/ImageCanvas/hooks/useKeyboardNavigation.tsx (1)
useKeyboardNavigation
(11-32)src/layout/ImageUpload/ImageCanvas/ImagePreview.tsx (1)
ImagePreview
(14-37)
src/layout/ImageUpload/ImageCanvas/hooks/useDragInteraction.tsx (1)
src/layout/ImageUpload/imageUploadUtils.ts (1)
Position
(6-6)
src/layout/ImageUpload/ImageCanvas/hooks/useCanvasDraw.tsx (1)
src/layout/ImageUpload/imageUploadUtils.ts (5)
Position
(6-6)CropArea
(15-15)imagePlacement
(61-68)cropAreaPlacement
(73-77)drawCropArea
(86-94)
src/layout/ImageUpload/ImageCropper.tsx (6)
src/layout/ImageUpload/imageUploadUtils.ts (10)
CropArea
(15-15)Position
(6-6)calculateMinZoom
(125-126)constrainToArea
(41-52)imagePlacement
(61-68)validateFile
(128-151)cropAreaPlacement
(73-77)drawCropArea
(86-94)getNewFileName
(170-173)IMAGE_TYPE
(4-4)src/layout/ImageUpload/useImageFile.tsx (1)
useImageFile
(17-52)src/layout/ImageUpload/ImageDropzone.tsx (1)
ImageDropzone
(18-58)src/layout/ImageUpload/ImageCanvas/ImageCanvas.tsx (1)
ImageCanvas
(26-65)src/layout/ImageUpload/ImageControllers.tsx (1)
ImageControllers
(26-159)src/features/language/Lang.tsx (1)
Lang
(15-23)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
- GitHub Check: Analyze (javascript)
- GitHub Check: Type-checks, eslint, unit tests and SonarCloud
- GitHub Check: Install
export const useCanvasDraw = ({ canvasRef, imageRef, zoom, position, cropArea }: UseCanvasDrawProps) => { | ||
useEffect(() => { | ||
const canvas = canvasRef.current; | ||
const ctx = canvas?.getContext('2d'); | ||
const img = imageRef.current; | ||
|
||
if (!canvas || !img?.complete || !ctx) { | ||
return; | ||
} | ||
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height); | ||
const { imgX, imgY, scaledWidth, scaledHeight } = imagePlacement({ canvas, img, zoom, position }); | ||
|
||
ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight); | ||
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; | ||
ctx.fillRect(0, 0, canvas.width, canvas.height); | ||
ctx.save(); | ||
|
||
const { cropAreaX, cropAreaY } = cropAreaPlacement({ canvas, cropArea }); | ||
drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea }); | ||
ctx.clip(); | ||
ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight); | ||
ctx.restore(); | ||
|
||
drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea }); | ||
ctx.strokeStyle = 'white'; | ||
ctx.lineWidth = 2; | ||
ctx.setLineDash([5, 5]); | ||
ctx.stroke(); | ||
ctx.setLineDash([]); | ||
}, [canvasRef, imageRef, zoom, position, cropArea]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Draw after image load; current effect can miss initial render.
The effect exits when !img?.complete
and never re-runs on load, so the canvas can stay blank until another state change occurs. Register a load listener and draw once the image finishes loading.
export const useCanvasDraw = ({ canvasRef, imageRef, zoom, position, cropArea }: UseCanvasDrawProps) => {
useEffect(() => {
- const canvas = canvasRef.current;
- const ctx = canvas?.getContext('2d');
- const img = imageRef.current;
-
- if (!canvas || !img?.complete || !ctx) {
- return;
- }
-
- ctx.clearRect(0, 0, canvas.width, canvas.height);
- const { imgX, imgY, scaledWidth, scaledHeight } = imagePlacement({ canvas, img, zoom, position });
-
- ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight);
- ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
- ctx.fillRect(0, 0, canvas.width, canvas.height);
- ctx.save();
-
- const { cropAreaX, cropAreaY } = cropAreaPlacement({ canvas, cropArea });
- drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea });
- ctx.clip();
- ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight);
- ctx.restore();
-
- drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea });
- ctx.strokeStyle = 'white';
- ctx.lineWidth = 2;
- ctx.setLineDash([5, 5]);
- ctx.stroke();
- ctx.setLineDash([]);
+ const canvas = canvasRef.current;
+ const ctx = canvas?.getContext('2d');
+ const img = imageRef.current;
+ if (!canvas || !ctx || !img) {
+ return;
+ }
+
+ const draw = () => {
+ if (!img.complete) return;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ const { imgX, imgY, scaledWidth, scaledHeight } = imagePlacement({ canvas, img, zoom, position });
+
+ ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight);
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.save();
+
+ const { cropAreaX, cropAreaY } = cropAreaPlacement({ canvas, cropArea });
+ drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea });
+ ctx.clip();
+ ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight);
+ ctx.restore();
+
+ drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea });
+ ctx.strokeStyle = 'white';
+ ctx.lineWidth = 2;
+ ctx.setLineDash([5, 5]);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ };
+
+ if (img.complete) {
+ draw();
+ } else {
+ img.addEventListener('load', draw, { once: true });
+ }
+
+ return () => {
+ img.removeEventListener('load', draw);
+ };
}, [canvasRef, imageRef, zoom, position, cropArea]);
};
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
export const useCanvasDraw = ({ canvasRef, imageRef, zoom, position, cropArea }: UseCanvasDrawProps) => { | |
useEffect(() => { | |
const canvas = canvasRef.current; | |
const ctx = canvas?.getContext('2d'); | |
const img = imageRef.current; | |
if (!canvas || !img?.complete || !ctx) { | |
return; | |
} | |
ctx.clearRect(0, 0, canvas.width, canvas.height); | |
const { imgX, imgY, scaledWidth, scaledHeight } = imagePlacement({ canvas, img, zoom, position }); | |
ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight); | |
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; | |
ctx.fillRect(0, 0, canvas.width, canvas.height); | |
ctx.save(); | |
const { cropAreaX, cropAreaY } = cropAreaPlacement({ canvas, cropArea }); | |
drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea }); | |
ctx.clip(); | |
ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight); | |
ctx.restore(); | |
drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea }); | |
ctx.strokeStyle = 'white'; | |
ctx.lineWidth = 2; | |
ctx.setLineDash([5, 5]); | |
ctx.stroke(); | |
ctx.setLineDash([]); | |
}, [canvasRef, imageRef, zoom, position, cropArea]); | |
export const useCanvasDraw = ({ canvasRef, imageRef, zoom, position, cropArea }: UseCanvasDrawProps) => { | |
useEffect(() => { | |
const canvas = canvasRef.current; | |
const ctx = canvas?.getContext('2d'); | |
const img = imageRef.current; | |
if (!canvas || !ctx || !img) { | |
return; | |
} | |
const draw = () => { | |
if (!img.complete) return; | |
ctx.clearRect(0, 0, canvas.width, canvas.height); | |
const { imgX, imgY, scaledWidth, scaledHeight } = | |
imagePlacement({ canvas, img, zoom, position }); | |
ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight); | |
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; | |
ctx.fillRect(0, 0, canvas.width, canvas.height); | |
ctx.save(); | |
const { cropAreaX, cropAreaY } = cropAreaPlacement({ canvas, cropArea }); | |
drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea }); | |
ctx.clip(); | |
ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight); | |
ctx.restore(); | |
drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea }); | |
ctx.strokeStyle = 'white'; | |
ctx.lineWidth = 2; | |
ctx.setLineDash([5, 5]); | |
ctx.stroke(); | |
ctx.setLineDash([]); | |
}; | |
if (img.complete) { | |
draw(); | |
} else { | |
img.addEventListener('load', draw, { once: true }); | |
} | |
return () => { | |
img.removeEventListener('load', draw); | |
}; | |
}, [canvasRef, imageRef, zoom, position, cropArea]); | |
}; |
🤖 Prompt for AI Agents
In src/layout/ImageUpload/ImageCanvas/hooks/useCanvasDraw.tsx around lines 20 to
50, the effect bails out when the image is not yet complete so the canvas may
never be drawn on initial load; add a load event listener on imageRef.current
that calls the same draw routine when the image finishes loading, and if the
image is already complete run the draw immediately; ensure you register the
listener inside the effect, remove it in the cleanup to avoid leaks, and keep
the existing early-return logic for missing refs/ctx while factoring the drawing
logic into a function that both the immediate path and the load handler call.
export const useZoomInteraction = ({ canvasRef, zoom, onZoomChange }: UseZoomInteractionProps) => { | ||
const handleWheel = useCallback( | ||
(e: WheelEvent) => { | ||
e.preventDefault(); | ||
onZoomChange(zoom - e.deltaY * 0.001); | ||
}, | ||
[zoom, onZoomChange], | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Clamp zoom to safe bounds.
Current logic allows zero/negative or extreme zoom values, causing rendering glitches or inverted images. Clamp to a reasonable range.
export const useZoomInteraction = ({ canvasRef, zoom, onZoomChange }: UseZoomInteractionProps) => {
const handleWheel = useCallback(
(e: WheelEvent) => {
e.preventDefault();
- onZoomChange(zoom - e.deltaY * 0.001);
+ const MIN_ZOOM = 0.1;
+ const MAX_ZOOM = 10;
+ const next = zoom - e.deltaY * 0.001;
+ const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, next));
+ onZoomChange(clamped);
},
[zoom, onZoomChange],
);
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
export const useZoomInteraction = ({ canvasRef, zoom, onZoomChange }: UseZoomInteractionProps) => { | |
const handleWheel = useCallback( | |
(e: WheelEvent) => { | |
e.preventDefault(); | |
onZoomChange(zoom - e.deltaY * 0.001); | |
}, | |
[zoom, onZoomChange], | |
); | |
export const useZoomInteraction = ({ canvasRef, zoom, onZoomChange }: UseZoomInteractionProps) => { | |
const handleWheel = useCallback( | |
(e: WheelEvent) => { | |
e.preventDefault(); | |
const MIN_ZOOM = 0.1; | |
const MAX_ZOOM = 10; | |
const next = zoom - e.deltaY * 0.001; | |
const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, next)); | |
onZoomChange(clamped); | |
}, | |
[zoom, onZoomChange], | |
); |
🤖 Prompt for AI Agents
In src/layout/ImageUpload/ImageCanvas/hooks/useZoomInteraction.tsx around lines
10 to 17, the wheel handler computes a new zoom that can become zero, negative,
or extremely large; change it to clamp the computed zoom to safe bounds (e.g.
minZoom = 0.1 and maxZoom = 4.0) before calling onZoomChange. Compute const next
= zoom - e.deltaY * 0.001; const clamped = Math.min(maxZoom, Math.max(minZoom,
next)); then call onZoomChange(clamped) (keep e.preventDefault() and the
useCallback deps).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
♻️ Duplicate comments (8)
src/layout/ImageUpload/imageUploadUtils.ts (5)
41-52
: Use natural image dimensions and guard against zero.This duplicates an earlier concern:
image.width/height
are CSS pixels; usenaturalWidth/naturalHeight
for pixel-accurate canvas calculations. Also guard against zero dimensions to preventInfinity/NaN
.Based on past review comments.
61-68
: Use natural image dimensions.Same issue as
constrainToArea
: useimg.naturalWidth/naturalHeight
instead ofimg.width/height
for accurate pixel calculations.Based on past review comments.
105-122
: Guard zoom calculations against invalid inputs.This duplicates an earlier concern:
Math.log
with non-positive values yields-Infinity/NaN
. Guard inputs and clamp outputs to prevent propagating invalid numbers.Based on past review comments.
125-126
: Use natural image dimensions.Same issue: use
img.naturalWidth/naturalHeight
instead ofimg.width/height
.Based on past review comments.
184-204
: Calculation logic is sound, but use natural dimensions.The zoom centering calculation correctly maintains the image center point during zoom transitions. However, this also uses
img.width/height
instead ofnaturalWidth/naturalHeight
as flagged in earlier comments.Based on past review comments for the dimension issue.
src/layout/ImageUpload/ImageCropper.tsx (3)
77-99
: Memory leak: Image object not cleaned up.This duplicates an earlier concern: the
Image
object created here is not cleaned up if the component unmounts or a new file is uploaded before loading completes. Track loading state and cancel in-flight loads.Based on past review comments.
101-131
: Handle toBlob failures with user feedback.This duplicates an earlier concern: the
toBlob
callback should log errors and show user feedback when blob creation fails, rather than silently returning.Based on past review comments.
181-195
: Non-null assertion can fail when restoring saved image.This duplicates an earlier concern:
imageTypeRef.current!
can benull
when the component mounts with an existingstoredImage
(no new upload). Use a fallback likeimageTypeRef.current ?? storedImage?.data?.contentType ?? IMAGE_TYPE
.Based on past review comments.
🧹 Nitpick comments (1)
src/layout/ImageUpload/imageUploadUtils.ts (1)
73-77
: Consider extracting center calculation constant (optional).The division by 2 finds the center point. While clear in context, you could optionally extract
const CENTER_DIVISOR = 2
if you want to make the centering intent more explicit throughout the file.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
src/layout/ImageUpload/ImageCropper.tsx
(1 hunks)src/layout/ImageUpload/imageUploadUtils.test.tsx
(1 hunks)src/layout/ImageUpload/imageUploadUtils.ts
(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- src/layout/ImageUpload/imageUploadUtils.test.tsx
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
**/*.{ts,tsx}
: Avoid usingany
and unnecessary type casts (as Type
) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options viaqueryOptions
Files:
src/layout/ImageUpload/ImageCropper.tsx
src/layout/ImageUpload/imageUploadUtils.ts
🧬 Code graph analysis (2)
src/layout/ImageUpload/ImageCropper.tsx (7)
src/layout/ImageUpload/imageUploadUtils.ts (11)
CropArea
(15-15)Position
(6-6)calculateMinZoom
(125-126)constrainToArea
(41-52)calculatePositionForZoom
(184-204)validateFile
(128-151)imagePlacement
(61-68)cropAreaPlacement
(73-77)drawCropArea
(86-94)getNewFileName
(170-173)IMAGE_TYPE
(4-4)src/layout/ImageUpload/useImageFile.tsx (1)
useImageFile
(17-52)src/layout/ImageUpload/ImageDropzone.tsx (1)
ImageDropzone
(18-58)src/app-components/Card/Card.tsx (1)
AppCard
(20-54)src/layout/ImageUpload/ImageCanvas/ImageCanvas.tsx (1)
ImageCanvas
(26-65)src/layout/ImageUpload/ImageControllers.tsx (1)
ImageControllers
(26-159)src/features/language/Lang.tsx (1)
Lang
(15-23)
src/layout/ImageUpload/imageUploadUtils.ts (1)
src/types/shared.ts (1)
IDataType
(56-68)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
- GitHub Check: Type-checks, eslint, unit tests and SonarCloud
- GitHub Check: Analyze (javascript)
- GitHub Check: Install
🔇 Additional comments (12)
src/layout/ImageUpload/imageUploadUtils.ts (6)
1-32
: LGTM!The type definitions and
getCropArea
function are well-structured. The logic correctly constrains circle crops to squares by using the minimum dimension.
86-94
: LGTM!The canvas path drawing correctly handles both rectangle and circle crop shapes using standard geometry.
128-151
: LGTM!The file validation logic correctly checks size (10 MB limit) and type (image/*), with clear error messaging using language keys.
153-156
: LGTM!The animation file detection correctly identifies GIF, APNG, and WebP formats with case-insensitive comparison.
163-168
: LGTM!The content type validation correctly handles both unrestricted uploads (empty array) and validates PNG support when restrictions are defined.
170-173
: LGTM!The filename transformation correctly strips the original extension and replaces it with
.png
.src/layout/ImageUpload/ImageCropper.tsx (6)
1-28
: LGTM!The component interface is well-defined with all necessary props including
readOnly
for proper form state handling.
32-42
: LGTM!The component initialization properly sets up refs, state, and calculates minimum zoom with a safe fallback.
43-75
: LGTM!The position and zoom handlers correctly constrain values to valid ranges and are properly memoized with appropriate dependencies.
133-148
: LGTM!The cleanup and state update handlers correctly reset the component state for delete, cancel, and reset operations.
150-162
: LGTM!The initial upload UI correctly passes through
readOnly
and error states to the dropzone component.
201-214
: LGTM!The validation messages component correctly handles null checks and renders localized error messages.
|
Description
New component ImageUpload. The main functionality of this component is to let the user upload an image and crop it before saving it to the instance. When it is saved, user will be displayed the result.
Validations are added: the file must be of type image and the size can't exceed 10MB. If user adds an image type that can contain animation such as
gif, apng or webp
, we inform user that only the first frame is displayed.The logic behind cropping the image, which is also used in other third-party libraries, uses the web API Canvas. It can draw the uploaded image according to our specifications, such as which part of the image should be drawn and kept inside the crop area.
The content of the crop area is determined by various utilities that calculate the position of the image (x, y) relative to the canvas, the size of the image relative to the canvas, the center of the crop area, clamped positions (restricting the user from dragging the crop area outside the image), etc. Most of these calculation functions can be found in the utility file.
This new component has some specific configuration options:
cropShape: enum["square", "circle"]
, where "circle" is the default.cropWidth
: the width of the crop area in pixels. Default is 250px.cropHeight
: the height of the crop area in pixels. Default is also 250px.These are optional props. If the user chooses not to set them, they will default to 250px and "circle".
Studio also supports this new component and is currently behind the feature flag:
imageUpload
.This component also supports keyboard users.
Altinn docs is updated.
Here is how the component will behave for the user:
imageupload.mp4
Related Issue(s)
Verification/QA
docs for new component - image upload altinn-studio-docs#2388
kind/*
andbackport*
label to this PR for proper release notes groupingSummary by CodeRabbit
New Features
Improvements
Tests